﻿#region Copyright (c) 2008 S. van Deursen
/* The CuttingEdge.Logging library allows developers to plug a logging mechanism into their web- and desktop
 * applications.
 * 
 * Copyright (C) 2008 S. van Deursen
 * 
 * To contact me, please visit my blog at http://www.cuttingedge.it/blogs/steven/ or mail to steven at 
 * cuttingedge.it.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 
 * associated documentation files (the "Software"), to deal in the Software without restriction, including 
 * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
 * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 
 * following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in all copies or substantial 
 * portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 
 * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 
 * EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 
 * USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#endregion

using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Configuration;
using System.Configuration.Provider;
using System.Data;
using System.Data.SqlClient;

namespace CuttingEdge.Logging
{
    /// <summary>
    /// Manages storage of logging information in a SQL Server database.
    /// </summary>
    /// <remarks>
    /// <para>
    /// When the <b>SqlLoggingProvider</b> succeeds writing the event to the database, the
    /// <see cref="ILogger.Log(LogEntry)">Log</see> method will return an <see cref="Int32"/> with the
    /// unique id for the logging event generated by the database. Please note that the provider will return 
    /// null when the event is not logged to the database. This happens in the following situations:
    /// <list type="bullet">
    ///     <item>The provider's <see cref="LoggingProviderBase.Threshold">Threshold</see> was set higher than
    ///     the event's <see cref="LogEntry.Severity">Severity</see>.</item>
    ///     <item>There was an exception logging to the database and the event was logged successfully to the
    ///     <see cref="LoggingProviderBase.FallbackProvider">FallbackProvider</see>.</item>
    /// </list>
    /// </para>
    /// <para>
    /// This class is used by the <see cref="Logger"/> class to provide Logging services for an 
    /// application using a SQL Server database. You cannot use a <see cref="SqlLoggingProvider"/>
    /// without SQL Server.
    /// </para>
    /// <para>
    /// The table below shows the list of valid attributes for the <see cref="SqlLoggingProvider"/>
    /// configuration:
    /// <list type="table">  
    /// <listheader>
    ///     <attribute>Attribute</attribute>
    ///     <description>Description</description>
    /// </listheader>
    /// <item>
    ///     <attribute>name</attribute>
    ///     <description>
    ///         The name of the provider. This attribute is mandatory.
    ///     </description>
    /// </item>
    /// <item>
    ///     <attribute>description</attribute>
    ///     <description>
    ///         A description of the provider. This attribute is optional.
    ///     </description>
    /// </item>
    /// <item>
    ///     <attribute>fallbackProvider</attribute>
    ///     <description>
    ///         A fallback provider that this provider will use when logging failed. The value must contain 
    ///         the name of an existing logging provider. This attribute is optional.
    ///     </description>
    /// </item>  
    /// <item>
    ///     <attribute>threshold</attribute>
    ///     <description>
    ///         The logging threshold. The threshold limits the number of events logged. The threshold can be
    ///         defined as follows: Debug &lt; Information &lt; Warning &lt; Error &lt; Critical. i.e., When
    ///         the threshold is set to Information, events with a severity of Debug  will not be logged. When
    ///         no value is specified, all events are logged. This attribute is optional.
    ///      </description>
    /// </item>  
    /// <item>
    ///     <attribute>connectionStringName</attribute>
    ///     <description>
    ///         The name of the connection string in the connectionString section of the application 
    ///         configuration file that will be used to connect to the database. This attribute is mandatory.
    ///     </description>
    /// </item>  
    /// <item>
    ///     <attribute>initializeSchema</attribute>
    ///     <description>
    ///         When this boolean attribute is set to true, the provider will try to create the needed tables 
    ///         and stored procedures in the database. This attribute is optional and false by default.
    ///     </description>
    /// </item>  
    /// </list>
    /// The attributes can be specified within the provider configuration. See the example below on how to
    /// use.
    /// </para>
    /// </remarks>
    /// <example>
    /// This example demonstrates how to specify values declaratively for several attributes of the
    /// logging section, which can also be accessed as members of the <see cref="LoggingSection"/> class.
    /// The following configuration file example shows how to specify values declaratively for the
    /// logging section.
    /// <code lang="xml"><![CDATA[
    /// <?xml version="1.0"?>
    /// <configuration>
    ///     <configSections>
    ///         <section name="logging" type="CuttingEdge.Logging.LoggingSection, CuttingEdge.Logging" />
    ///     </configSections>
    ///     <connectionStrings>
    ///         <add name="SqlLogging" 
    ///             connectionString="Data Source=.;Integrated Security=SSPI;Initial Catalog=Logging;" />
    ///     </connectionStrings>
    ///     <logging defaultProvider="SqlLoggingProvider">
    ///         <providers>
    ///             <add 
    ///                 name="SqlLoggingProvider"
    ///                 type="CuttingEdge.Logging.SqlLoggingProvider, CuttingEdge.Logging"
    ///                 threshold="Information"
    ///                 connectionStringName="SqlLogging"
    ///                 initializeSchema="True"
    ///                 description="SQL logging provider"
    ///             />
    ///         </providers>
    ///     </logging>
    /// </configuration>
    /// ]]></code>
    /// </example>
    public class SqlLoggingProvider : LoggingProviderBase
    {
        internal const string AddEventProcedure = "dbo.logging_AddEvent";
        internal const string AddExceptionProcedure = "dbo.logging_AddException";

        private string connectionString;

        /// <summary>
        /// Initializes a new instance of the <see cref="SqlLoggingProvider"/> class. Please do not use 
        /// this constructor to initialize this type. Use one of the overloaded constructors instead.
        /// </summary>
        public SqlLoggingProvider()
        {
            // Set Initialized to false explicitly to prevent this type from being used until it is correctly
            // initialized using Initialize().
            this.SetInitialized(false);
        }

        /// <summary>Initializes a new instance of the <see cref="SqlLoggingProvider"/> class.</summary>
        /// <param name="threshold">The <see cref="LoggingEventType"/> logging threshold. The threshold limits
        /// the number of event logged. <see cref="LoggingProviderBase.Threshold">Threshold</see> for more 
        /// information.</param>
        /// <param name="connectionString">The connection string.</param>
        /// <exception cref="InvalidEnumArgumentException">Thrown when <paramref name="threshold"/> has an
        /// invalid value.</exception>
        /// <exception cref="ArgumentNullException">Thrown when the <paramref name="connectionString"/> is a
        /// null reference (Nothing in VB).</exception>
        /// <exception cref="ArgumentException">Thrown when the <paramref name="connectionString"/> is an
        /// empty string.</exception>
        public SqlLoggingProvider(LoggingEventType threshold, string connectionString) 
            : this(threshold, connectionString, null)
        {
        }

        /// <summary>Initializes a new instance of the <see cref="SqlLoggingProvider"/> class.</summary>
        /// <param name="threshold">The <see cref="LoggingEventType"/> logging threshold. The threshold limits
        /// the number of event logged. <see cref="LoggingProviderBase.Threshold">Threshold</see> for more 
        /// information.</param>
        /// <param name="connectionString">The connection string.</param>
        /// <param name="fallbackProvider">The optional fallback provider.</param>
        /// <exception cref="InvalidEnumArgumentException">Thrown when <paramref name="threshold"/> has an
        /// invalid value.</exception>
        /// <exception cref="ArgumentNullException">Thrown when the <paramref name="connectionString"/> is a
        /// null reference (Nothing in VB).</exception>
        /// <exception cref="ArgumentException">Thrown when the <paramref name="connectionString"/> is an
        /// empty string.</exception>
        public SqlLoggingProvider(LoggingEventType threshold, string connectionString, 
            LoggingProviderBase fallbackProvider) : base(threshold, fallbackProvider)
        {
            if (connectionString == null)
            {
                throw new ArgumentNullException("connectionString");
            }

            if (connectionString.Length == 0)
            {
                throw new ArgumentException(SR.ValueShouldNotBeAnEmptyString(), "connectionString");
            }

            this.connectionString = connectionString;
        }

        /// <summary>Gets the connection string provided with this provider.</summary>
        /// <value>The connection string.</value>
        public string ConnectionString
        {
            get { return this.connectionString; }
        }

        /// <summary>Initializes the provider.</summary>
        /// <param name="name">The friendly name of the provider.</param>
        /// <param name="config">A collection of the name/value pairs representing the provider-specific
        /// attributes specified in the configuration for this provider.</param>
        /// <exception cref="ArgumentNullException">Thrown when <paramref name="config"/> is null.</exception>
        /// <exception cref="ArgumentException">Thrown when the name of the provider has a length of zero.</exception>
        /// <exception cref="InvalidOperationException">Thrown when an attempt is made to call Initialize on a
        /// provider after the provider has already been initialized.</exception>
        /// <exception cref="ProviderException">Thrown when the <paramref name="config"/> contains
        /// unrecognized attributes or when the connectionStringName attribute is not configured properly.</exception>
        public override void Initialize(string name, NameValueCollection config)
        {
            if (config == null)
            {
                throw new ArgumentNullException("config");
            }

            LoggingHelper.SetDescriptionWhenMissing(config, "SQL logging provider");

            // Call initialize first.
            base.Initialize(name, config);

            // Performing implementation-specific provider initialization here.
            this.InitializeConnectionString(config);

            bool mustInitializeSchema = this.GetInitializeSchemaAttributeFromConfig(config);

            // Check if the configuration is valid, before initializing the database.
            this.CheckForUnrecognizedAttributes(name, config);

            // When the initialization of the database schema is registered in the configuration file, we
            // execute creation of tables and stored procedures.
            if (mustInitializeSchema)
            {
                this.InitializeDatabaseSchema();
            }

            this.SetInitialized(true);
        }

        internal int LogWithinTransaction(LogEntry entry, SqlTransaction transaction)
        {
            // Log the message
            int eventId = this.SaveEventToDatabase(transaction, entry.Severity, entry.Message, entry.Source);

            if (entry.Exception != null)
            {
                this.SaveExceptionChainToDatabase(transaction, entry.Exception, eventId);
            }

            return eventId;
        }

        /// <summary>Initializes the database schema.</summary>
        protected virtual void InitializeDatabaseSchema()
        {
            try
            {
                SqlLoggingHelper.ThrowWhenSchemaAlreadyHasBeenInitialized(this);

                string createScript = SR.SqlLoggingProviderSchemaScripts();
                
                // Split the script in separate operations. SQL Server chokes on the GO statements.
                string[] createScripts = createScript.Split(new string[] { "GO" }, StringSplitOptions.None);

                SqlLoggingHelper.CreateTablesAndStoredProcedures(this, createScripts);
            }
            catch (SqlException ex)
            {
                throw new ProviderException(SR.InitializationOfDatabaseSchemaFailed(this.Name, ex.Message), ex);
            }
        }

        /// <summary>Implements the functionality to log the event.</summary>
        /// <param name="entry">The entry to log.</param>
        /// <returns>An <see cref="Int32"/> with the id of the logged event.</returns>
        /// <exception cref="SqlException">Thrown when the <paramref name="entry"/> could not be logged to the
        /// database.</exception>
        /// <exception cref="ArgumentNullException">Thrown when <paramref name="entry"/> is a null (Nothing
        /// in VB) reference.</exception>
        protected override object LogInternal(LogEntry entry)
        {
            if (entry == null)
            {
                throw new ArgumentNullException("entry");
            }

            using (var connection = new SqlConnection(this.ConnectionString))
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    int eventId = this.LogWithinTransaction(entry, transaction);

                    transaction.Commit();

                    return eventId;
                }
            }
        }

        /// <summary>Saves the event to database.</summary>
        /// <param name="transaction">The transaction.</param>
        /// <param name="severity">The severity of the event.</param>
        /// <param name="message">The message.</param>
        /// <param name="source">The source.</param>
        /// <returns>The database's primary key of the saved event.</returns>
        /// <exception cref="ArgumentNullException">Thrown when <paramref name="transaction"/> is a null 
        /// (Nothing in VB) reference.</exception>
        protected virtual int SaveEventToDatabase(SqlTransaction transaction, LoggingEventType severity, 
            string message, string source)
        {
            if (transaction == null)
            {
                throw new ArgumentNullException("transaction");
            }

            using (var command = new SqlCommand(AddEventProcedure, transaction.Connection, transaction))
            {
                command.CommandType = CommandType.StoredProcedure;

                SqlLoggingHelper.AddParameter(command, "EventTypeId", SqlDbType.Int, (int)severity);
                SqlLoggingHelper.AddParameter(command, "Message", SqlDbType.NText, message);
                SqlLoggingHelper.AddParameter(command, "Source", SqlDbType.NText, source);

                object eventId = command.ExecuteScalar();

                if (!(eventId is int))
                {
                    throw new InvalidOperationException(SR.StoredProcedureReturnedInvalidValue(
                        AddEventProcedure, eventId, typeof(int)));
                }

                return (int)eventId;
            }
        }

        /// <summary>Saves the exception to database.</summary>
        /// <param name="transaction">The transaction.</param>
        /// <param name="exception">The exception.</param>
        /// <param name="parentEventId">The parent event id.</param>
        /// <param name="parentExceptionId">The parent exception id.</param>
        /// <returns>The database's primary key of the saved exception.</returns>
        /// <exception cref="ArgumentNullException">Thrown when either <paramref name="transaction"/> or
        /// <paramref name="exception"/> are null (Nothing in VB) references.</exception>
        protected virtual int SaveExceptionToDatabase(SqlTransaction transaction, Exception exception,
            int parentEventId, int? parentExceptionId)
        {
            if (exception == null)
            {
                throw new ArgumentNullException("exception");
            }

            if (transaction == null)
            {
                throw new ArgumentNullException("transaction");
            }

            using (var command = new SqlCommand(AddExceptionProcedure, transaction.Connection, transaction))
            {
                command.CommandType = CommandType.StoredProcedure;

                SqlLoggingHelper.AddParameter(command, "EventId", SqlDbType.Int, parentEventId);
                SqlLoggingHelper.AddParameter(command, "ParentExceptionId", SqlDbType.Int, parentExceptionId);
                SqlLoggingHelper.AddParameter(command, "ExceptionType", SqlDbType.NVarChar, exception.GetType().Name);
                SqlLoggingHelper.AddParameter(command, "Message", SqlDbType.NText, exception.Message);
                SqlLoggingHelper.AddParameter(command, "StackTrace", SqlDbType.NText, exception.StackTrace);

                object exceptionId = command.ExecuteScalar();

                if (!(exceptionId is int))
                {
                    throw new InvalidOperationException(SR.StoredProcedureReturnedInvalidValue(
                        AddExceptionProcedure, exceptionId, typeof(int)));
                }

                return (int)exceptionId;
            }
        }

        private bool GetInitializeSchemaAttributeFromConfig(NameValueCollection config)
        {
            const string InitializeSchemaAttribute = "initializeSchema";

            string initializeSchema = config[InitializeSchemaAttribute];

            // Remove this attribute from the configuration. This way the provider can spot unrecognized
            // attributes after the initialization process.
            config.Remove(InitializeSchemaAttribute);

            const bool DefaultValueWhenMissing = false;

            return SqlLoggingHelper.ParseBoolConfigValue(this.Name, InitializeSchemaAttribute,
                initializeSchema, DefaultValueWhenMissing);
        }

        private void SaveExceptionChainToDatabase(SqlTransaction transaction, Exception exception,
            int eventId)
        {
            int exceptionId = this.SaveExceptionToDatabase(transaction, exception, eventId, null);

            this.SaveInnerExceptionsToDatabase(transaction, exception, eventId, exceptionId);
        }

        private void SaveInnerExceptionsToDatabase(SqlTransaction transaction, Exception parentException,
            int eventId, int parentExceptionId)
        {
            foreach (var innerException in LoggingHelper.GetInnerExceptions(parentException))
            {
                int exceptionId = 
                    this.SaveExceptionToDatabase(transaction, innerException, eventId, parentExceptionId);

                this.SaveInnerExceptionsToDatabase(transaction, innerException, eventId, exceptionId);
            }
        }

        private void InitializeConnectionString(NameValueCollection config)
        {
            const string ConnectionStringNameAttribute = "connectionStringName";

            string connectionStringName = config[ConnectionStringNameAttribute];

            // Throw exception when no connectionStringName is provided
            if (string.IsNullOrEmpty(connectionStringName))
            {
                throw new ProviderException(SR.MissingAttribute(ConnectionStringNameAttribute, this.Name));
            }

            var settings = ConfigurationManager.ConnectionStrings[connectionStringName];

            // Throw exception when connection string is missing from the <connectionStrings> section.
            if (settings == null || String.IsNullOrEmpty(settings.ConnectionString))
            {
                throw new ProviderException(SR.MissingConnectionStringInConfig(connectionStringName));
            }

            // Remove this attribute from the config. This way the provider can spot unrecognized attributes
            // after the initialization process.
            config.Remove(ConnectionStringNameAttribute);

            this.connectionString = settings.ConnectionString;
        }
    }
}